30 — 动画星系
介绍
你也可以在 particles 中使用 shader。正如我们在 Particles 课程中看到的,出于性能原因,为 geometry 的每个 vertex 做动画并不是高效的方案。这时 GPU 就派上了用场,可以直接在 vertex shader 中为这些 vertex 做动画。
在本课中,我们将从 particle 星系开始。我们会用 vertex shader 为 particles 做动画,让星星旋转,但根据距中心的距离以不同速度旋转;同时我们会在 particles 中绘制图案,来替代那些难看的方块。
设置
起始项目与 Galaxy Generator 课程的起始项目几乎相同。唯一的区别是缺少了自转公式,因为我们将在 shader 中完成自转动画。
用 ShaderMaterial 替换 PointsMaterial
目前 particles 使用的是 PointsMaterial,但如果想编写自己的 shader,我们就需要使用 ShaderMaterial。
将 PointsMaterial 替换为 ShaderMaterial:
material = new THREE.ShaderMaterial({
// ...
})
如果你查看日志,应该会看到两条警告,提示 ShaderMaterial 既不支持 size 也不支持 sizeAttenuation。我们需要自行添加这些特性。目前,先移除这些属性:
material = new THREE.ShaderMaterial({
depthWrite: false,
blending: THREE.AdditiveBlending,
vertexColors: true
}) 此时此刻,有些人可能会看到 particles 像微小的红点,有些人可能会看到黑屏。这取决于当未提供 size 时,你的 GPU 如何处理 particles。我们不会在这上面浪费时间,因为无论如何我们都会设置 size,届时每个人都应该能看到 particles。
显然,我们需要提供自己的 shader。添加以下 vertexShader:
material = new THREE.ShaderMaterial({
// ...
vertexShader: `
void main()
{
/**
* Position
*/
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectedPosition = projectionMatrix * viewPosition;
gl_Position = projectedPosition;
/**
* Size
*/
gl_PointSize = 2.0;
}
`
})
shader 的开头与我们之前见过的一样。我们依次使用 modelMatrix、viewMatrix 和 projectionMatrix 来更新 position。但随后,我们给一个名为 gl_PointSize 的新变量赋值为 2.0。
gl_PointSize 正是你所想的那样。particles 将具有 2x2 的大小,无论 camera 距离多远,你都应该看到 2x2 大小的 particles。
这里的单位是 fragment,如果你使用的是像素比为 1 的普通屏幕,你会得到 2 像素乘 2 像素的大小,因为 1 个 fragment = 1 个像素。但如果你使用的是像素比更高的屏幕,比如 retina 屏,1 个 fragment 会比 1 个像素小,你看到的 particles 也会更小。我们稍后会修复这一点,以便在任何像素比下都能获得一致的结果。
在我们改进 particles 大小之前,先来更改颜色。
目前 particles 是红色的,因为我们没有提供任何 fragmentShader,而 Three.js 会使用一个输出红色的默认 shader。
添加以下输出白色颜色的 fragment shader:
material = new THREE.ShaderMaterial({
// ...
fragmentShader: `
void main()
{
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
#include <colorspace_fragment>
}
`
})
所有 particles 都应该是白色的。
正如我们在之前的课程中看到的,#include <colorspace_fragment> 会以 sRGB 输出颜色,从而与 WebGLRenderer 的色彩空间匹配,改善色彩管理。
void main()
{
// ...
#include <colorspace_fragment>
} 你会在课程的后续部分了解到这个 #include 是什么。
将 shader 移到单独的文件中
现在是很好的时机,把 shader 移到单独的文件中,以免它们变得过长而难以管理。
在 /src/ 中创建一个 shaders/,然后在其中创建一个 galaxy/ 文件夹。
在该文件夹内,创建一个 vertex.glsl 文件,内容为 vertexShader 属性的内容:
void main()
{
/**
* Position
*/
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
vec4 viewPosition = viewMatrix * modelPosition;
vec4 projectedPosition = projectionMatrix * viewPosition;
gl_Position = projectedPosition;
/**
* Size
*/
gl_PointSize = 2.0;
} 再创建一个 fragment.glsl:
void main()
{
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
} 项目配置已经支持 .glsl 文件。我们只需在 JavaScript 中导入它们,并在 material 中使用:
import galaxyVertexShader from './shaders/galaxy/vertex.glsl'
import galaxyFragmentShader from './shaders/galaxy/fragment.glsl'
// ...
material = new THREE.ShaderMaterial({
// ...
vertexShader: galaxyVertexShader,
fragmentShader: galaxyFragmentShader
})
结果应该是一样的。
你并非必须把 shader 放到单独的文件中,但这是个好习惯,语法高亮还能帮你避免出错。
另外,如果你按照之前课程的建议安装了 linter,那么在刷新之前就能看到潜在的错误。
处理大小
基础大小
首先,我们为每个 particle 添加一个基础大小,并且希望能从 JavaScript 中更改这个值。为此,我们给 material 添加常见的 uniforms 属性,其中包含一个 uSize uniform:
material = new THREE.ShaderMaterial({
// ...
uniforms:
{
uSize: { value: 8 }
},
// ...
}) 现在我们可以在 vertexShader 中获取这个值,并将其用于 gl_PointSize:
uniform float uSize;
void main()
{
// ...
gl_PointSize = uSize;
}
它们现在看起来挺大的,但很快就会变小。
随机化大小
在现实中,星星的大小各不相同。我们来添加一些随机性。我们希望为每个 vertex 关联一个不同的值。我们将使用一个 attribute。
给 geometry 添加一个 aScale attribute。我们已经有 position 和 color attribute,可以按照同样的方式轻松添加这个新的 attribute:
geometry = new THREE.BufferGeometry()
const positions = new Float32Array(parameters.count * 3)
const colors = new Float32Array(parameters.count * 3)
const scales = new Float32Array(parameters.count * 1)
// ...
for(let i = 0; i < parameters.count; i++)
{
// ...
// Scale
scales[i] = Math.random()
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3))
geometry.setAttribute('aScale', new THREE.BufferAttribute(scales, 1)) 在创建 Float32Array 和 BufferAttribute 时,务必使用 1 而不是 3,因为这个值是 float,不像其他的是 vec3——每个 vertex 只需要一个值。我们还将 attribute 命名为 aScale,在前面加了一个 a。
你可能会想把 position 和 color attribute 改成 aPosition 和 aColor,但这会导致 bug,因为我们使用的是 ShaderMaterial,它会在我们的 vertex shader 前面预置代码,这些代码会添加类似 attribute vec3 position; 和 attribute vec3 color; 的内容。
现在我们可以在 vertex shader 中获取这个 attribute,并用它乘以 uSize:
uniform float uSize;
attribute float aScale;
void main()
{
// ...
gl_PointSize = uSize * aScale;
}
你应该会看到大小随机的 particles。
修复像素比
然而,我们的 particles 有个问题。它们的大小取决于屏幕的像素比。还记得我们用下面这行代码更新了 renderer 的像素比:
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) 如果你使用的屏幕像素比为 1,那么 particles 看起来会比像素比为 2 的屏幕大 2 倍。
我们需要一个解决方案,让 particle 大小在任何像素比下都保持一致。
实现这一点有多种方法。最简单的方法是将 uSize 的值乘以 renderer 的像素比。我们可以用 getPixelRatio() 方法获取这个像素比:
material = new THREE.ShaderMaterial({
// ...
uniforms:
{
uSize: { value: 8 * renderer.getPixelRatio() }
}
// ...
}) 不幸的是,这段代码不会生效,因为我们在创建 renderer 之前就创建了 material。要解决这个问题,只需将 generateGalaxy 的第一次调用移到实例化 renderer 之后:
/**
* Renderer
*/
const renderer = new THREE.WebGLRenderer({
canvas: canvas
})
// ...
/**
* Generate the first galaxy
*/
generateGalaxy()
现在无论像素比如何,particles 看起来都一样了。
大小衰减
我们移除了 sizeAttenuation 属性,因为 ShaderMaterial 不支持它。我们需要自行实现这种大小衰减。
提醒一下,大小衰减会让远离 camera 的 particles 变小,靠近 camera 的变大。这会模拟透视效果。
与其尝试猜测获得正确大小的公式,我们不如直接到 Three.js 依赖文件夹中,在 PointsMaterial 代码里找到处理这部分逻辑的代码。
虽然 Three.js 库文件夹中有大量代码,但它组织良好、易于浏览。不要犹豫,花些时间去阅读并熟悉它。
你可以在 /node_modules/three/src/renderers/shaders/ShaderLib/point_vert.glsl.js 中找到处理这部分逻辑的 shader,它应该看起来像这样:
#ifdef USE_SIZEATTENUATION
bool isPerspective = isPerspectiveMatrix( projectionMatrix );
if ( isPerspective ) gl_PointSize *= ( scale / - mvPosition.z );
#endif 我们唯一需要的部分是这一段:
gl_PointSize *= ( scale / - mvPosition.z ); 为了实现大小衰减,我们需要用这个公式 ( scale / - mvPosition.z ) 乘以 gl_PointSize
根据 Three.js 的说法,scale 是一个与渲染高度相关的值。为了便于处理,我们可以将它替换为 1.0。
mvPosition 对应应用 modelMatrix 和 viewMatrix 后 vertex 的位置。在我们的例子中,它就是我们的 viewPosition 变量。
这听起来可能有点复杂,但我们可以这样写:
gl_PointSize = uSize * aScale;
gl_PointSize *= (1.0 / - viewPosition.z);
让 camera 靠近 particles,就能看到它们变大。我们实现了大小衰减。
绘制我们的 particle 图案
是时候绘制更好看的 particle 了。就像在 Shader Patterns 课程中一样,我们首先需要 UV 坐标。遗憾的是,我们无法将 UV 从 vertex shader 传递到 fragment shader。记住,vertex shader 控制每个 particle 的位置,而在 vertex 的位置会出现一个面向 camera 的正方形平面。
好消息是,在 fragment shader 中我们已经可以通过 gl_PointCoord 访问 UV。这个变量是 particles 特有的。
把它添加到 fragment shader 中看看结果:
void main()
{
gl_FragColor = vec4(gl_PointCoord, 1.0, 1.0);
}
你应该会在每个 particle 上看到常见的 UV 图案。
现在是尝试绘制一些星星形状的好时机。你可以从圆盘开始,然后是点光源,再然后何不试试卡通风格的星形,或者任何你想要的东西。请记住,做到这一点需要练习,你最初的尝试可能并不理想,但你仍会积累经验。
圆盘图案
要得到圆盘:
- 获取
gl_PointCoord与中心点(vec2(0.5))之间的距离。 - 应用 step 函数,如果距离小于
0.5则得到0.0,如果距离大于0.5则得到1.0。 - 将值反转。
然后,我们将 strength 用于 r、g 和 b:
void main()
{
// Disc
float strength = distance(gl_PointCoord, vec2(0.5));
strength = step(0.5, strength);
strength = 1.0 - strength;
gl_FragColor = vec4(vec3(strength), 1.0);
}
漫射点图案
要得到漫射点:
- 获取
gl_PointCoord与中心点(vec2(0.5))之间的距离。 - 将它乘以
2.0,使其在触碰到边缘之前就达到1.0。 - 将值反转。
void main()
{
// Diffuse point
float strength = distance(gl_PointCoord, vec2(0.5));
strength *= 2.0;
strength = 1.0 - strength;
gl_FragColor = vec4(vec3(strength), 1.0);
}
这样好多了,但仍然缺乏真实感。我们缺少的是一个非常明亮、但会迅速变暗的中心。
光点图案
要得到光点:
- 获取
gl_PointCoord与中心点(vec2(0.5))之间的距离。 - 将值反转。
- 对它应用一个高次幂。
void main()
{
// Light point
float strength = distance(gl_PointCoord, vec2(0.5));
strength = 1.0 - strength;
strength = pow(strength, 10.0);
gl_FragColor = vec4(vec3(strength), 1.0);
}
这个解决方案的好处是,我们可以通过 pow() 的值来控制光晕的聚集程度。
我们将采用这个图案。因为光看起来更小了,我们把 uSize 稍微调大一些:
material = new THREE.ShaderMaterial({
// ...
uniforms:
{
uSize: { value: 30 * renderer.getPixelRatio() }
},
// ...
})
不幸的是,对于某些电脑来说,我们正在接近性能极限,你可能会遇到帧率下降。如果是这样,请减少 particles 的数量或它们的大小。
处理颜色
在这个过程中,我们丢失了颜色。好消息是我们的 shader 部分支持这些颜色。我们只需使用它们的值即可。
要获取 color attribute,我们本应在 vertex shader 中写类似这样的代码:
attribute vec3 color; 因为我们使用的是 ShaderMaterial 而不是 RawShaderMaterial,所以不需要这样做。shader 编译时会自动在前面添加该代码。我们只需把它传给 fragment shader。为此,我们将使用一个名为 vColor 的 varying,并用 color attribute 更新该 varying:
// ...
varying vec3 vColor;
void main()
{
// ...
/**
* Color
*/
vColor = color;
} 然后我们可以在 fragment shader 中用相同的 varying 声明获取它,并根据 strength 在黑色和 vColor 之间使用 mix(...):
varying vec3 vColor;
void main()
{
// Light point
float strength = distance(gl_PointCoord, vec2(0.5));
strength = 1.0 - strength;
strength = pow(strength, 10.0);
// Final color
vec3 color = mix(vec3(0.0), vColor, strength);
gl_FragColor = vec4(color, 1.0);
}
现在你能看到原始的颜色了。
动画
该做动画了。首先,我们使用常见的 uTime uniform。将它添加到 uniforms 中,并在 tick 函数中更新它的值:
material = new THREE.ShaderMaterial({
// ...
uniforms:
{
uTime: { value: 0 },
uSize: { value: 30 * renderer.getPixelRatio() }
},
// ...
})
// ...
const clock = new THREE.Clock()
const tick = () =>
{
const elapsedTime = clock.getElapsedTime()
// Update material
material.uniforms.uTime.value = elapsedTime
// ...
} 然后我们可以把 uTime 添加到我们的 shader 中:
uniform float uTime; 这个动画会很普通。我们会让星星旋转,但越靠近中心,旋转越快。
以下代码发生在 vertex shader 中 modelPosition 声明之后。提醒一下,modelPosition 是应用了 mesh 的 position、rotation 和 scale 之后 vertex 的位置。我们现在必须更新这个变量。
以下是过程:
- 我们计算 particle 的角度——从星系上方俯视——以及它到中心的距离。
- 我们使用距中心的距离和
uTime来增大该角度。离中心越远,速度越慢。 - 我们根据这个新的角度更新位置。
我们将使用一些三角函数。
旋转只发生在 x 和 z 轴上,我们可以保持 y 值不变,这大大简化了整个过程。
首先,用 atan(...) 获取角度:
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
// Rotate
float angle = atan(modelPosition.x, modelPosition.z); atan 代表 arc-tangent(反正切),你可以在这里了解更多:https://thebookofshaders.com/glossary/?search=atan
然后,用 length() 获取到中心的距离,它其实就是向量的长度:
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
// Rotate
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz); 然后,我们计算一个偏移角度。正如之前所说,越靠近中心,角度越大。我们还把这个值乘以 uTime,再乘以 0.2 来减慢效果的速率:
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
// Rotate
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz);
float angleOffset = (1.0 / distanceToCenter) * uTime * 0.2; 然后我们将这个 angleOffset 应用到基础的 angle 上:
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
// Rotate
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz);
float angleOffset = (1.0 / distanceToCenter) * uTime * 0.2;
angle += angleOffset; 最后,我们用 cos(...) 和 sin(...) 更新 modelPosition 的 x 和 z 轴:
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
// Rotate
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz);
float angleOffset = (1.0 / distanceToCenter) * uTime * 0.2;
angle += angleOffset;
modelPosition.x = cos(angle);
modelPosition.z = sin(angle);
虽然这看起来很棒,但这并不是我们想要的结果。cos(...) 和 sin(...) 返回的是半径为 1 的圆上的位置。这就是为什么所有 particles 看起来像是在一个圆柱面上旋转。
要修复这个问题,我们只需把 cos(...) 和 sin(...) 乘以 vertex 的初始半径,这个值我们已经保存在 distanceToCenter 中:
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
// Rotate
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz);
float angleOffset = (1.0 / distanceToCenter) * uTime * 0.2;
angle += angleOffset;
modelPosition.x = cos(angle) * distanceToCenter;
modelPosition.z = sin(angle) * distanceToCenter;
所有 vertex 都应该漂亮地旋转起来。
修复随机性
如果你稍等一会,就会发现星星似乎形成了带状。就好像随机性在 x 和 z 轴上不再起作用了。这是由于旋转公式把星星拉伸成了自旋的图案。
要修复这个问题,我们可以从 position attribute 中移除随机性,把它保存到一个名为 aRandomness 的新 attribute 中。然后在 vertex shader 中旋转星星之后再应用这个随机性。
创建这个 attribute 并把随机性存入其中。别忘了从 positions 中移除随机性:
geometry = new THREE.BufferGeometry()
const positions = new Float32Array(parameters.count * 3)
const randomness = new Float32Array(parameters.count * 3)
// ...
for(let i = 0; i < parameters.count; i++)
{
// ...
positions[i3 ] = Math.cos(branchAngle) * radius
positions[i3 + 1] = 0
positions[i3 + 2] = Math.sin(branchAngle) * radius
randomness[i3 ] = randomX
randomness[i3 + 1] = randomY
randomness[i3 + 2] = randomZ
// ...
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
geometry.setAttribute('aRandomness', new THREE.BufferAttribute(randomness, 3))
// ... 在 vertex shader 中,获取这个 attribute,并在应用旋转之后把它加到 modelPosition 的 xyz 上:
// ...
attribute vec3 aRandomness;
attribute float aScale;
// ...
void main()
{
/**
* Position
*/
vec4 modelPosition = modelMatrix * vec4(position, 1.0);
// Rotate
float angle = atan(modelPosition.x, modelPosition.z);
float distanceToCenter = length(modelPosition.xz);
float angleOffset = (1.0 / distanceToCenter) * uTime * 0.2;
angle += angleOffset;
modelPosition.x = cos(angle) * distanceToCenter;
modelPosition.z = sin(angle) * distanceToCenter;
// Randomness
modelPosition.xyz += aRandomness;
// ...
}
结果应该会好很多,带状也会消失。
减小随机性参数以获得更好的结果:
parameters.randomness = 0.2
更进一步
你也可以把 uSize uniform 添加到调试面板中。
几分钟后,我们就无法完全分辨星系的旋臂了。你可以添加一个重置按钮,或者放慢速度。
星系中心通常有一个巨大的黑洞。何不尝试创建一个呢?